跳到主要内容

Java JVM学习-方法区

方法区是什么的?

它用于存储已被虚拟机加载的类型信息(构造方法 / 接口定义)、常量、静态变量、即时编译器编译后的代码缓存等。

如下随便写个 HelloWorld

public class Temp {
public static void main(String[] args) throws InterruptedException {
System.out.println("Hello World");

TimeUnit.SECONDS.sleep(1000);
}
}

打开 VisualVM 可以发现就加载了 1620 个类

方法区(Method Area)与 Java堆一样, 是各个线程共享的内存区域。

方法区在 JVM启动的时候被创建,并且它的实际的物理内存空间中和 Java堆区一样都可以是不连续的。

方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java. lang.OutOfMemoryError: PermGen space(JDK8 前的永久代) 或者 java.lang.OutOfMemoryError: Metaspace

关闭 JVM就会释放这个区域的内存。

栈、堆、方法区的交互关系

从线程共享与否的角度来说

这里的元空间就是方法区的具体实现

而创建一个对象所涉及到的各个区域

可以看到引用类型也是存在 Java栈里面的槽(Slot)里面,就是对应上面那个 person 变量

方法区在哪里?

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。” 但对于 HotSpotJVM而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。

所以,方法区看作是一块独立于 Java堆的内存空间。

但这只是规范,具体实现得看各个虚拟机,如下介绍了在 Hotspot 里方法区的实现,元空间和永久代

元空间和永久代

Java7 的 JVM 结构图

Java7 及以前版本的细化 JVM 结构图

从图中可以看出,在 7 以及之前堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存,下面的图可能可以帮助我们更好的理解。

永久代和方法区的关系?

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。

“永久代(Permanet Generation,也称 PermGen)”。对于习惯了在 HotSpot 虚拟机上开发、部署的程序员来说,很多人都愿意将方法区称作永久代。

方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,本质上来讲两者并不等价,仅因为 Hotspot 将 GC 分代扩展至方法区,或者说使用永久代来实现方法区。在其它虚拟机上是没有永久代的概念的,永久代是 Hotspot 针对该规范进行的实现。

Java7 及以前版本的 Hotspot 中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。

注意:永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。

然后,在 Java8 中,时代变了,Hotspot 取消了永久代。

元空间和方法区的关系?

对于 Java8,HotSpots 取消了永久代,那么是不是就没有方法区了呢?

当然不是,方法区只是一个规范,只不过它的实现变了。

在 Java8 中,元空间(Metaspace)代替了永久代,而方法区存在于元空间(Metaspace)中。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

针对 Java8 的调整,我们再次对内存结构图进行调整。现在 JVM 的运行时数据区变成下面这样:

元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中的 “java.lang.OutOfMemoryError: PermGenspace”

默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM 同样提供了参数来限制它使用的使用。

-XX:MetaspaceSize,class metadata 的初始空间配额,以 bytes 为单位,达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize(如果设置了的话),适当的提高该值。

-XX:MaxMetaspaceSize,可以为class metadata分配的最大空间。默认是没有限制的。

-XX:MinMetaspaceFreeRatio,在GC之后,最小的 Metaspace 剩余空间容量的百分比,减少为 class metadata 分配空间导致的垃圾收集。

-XX:MaxMetaspaceFreeRatio,在GC之后,最大的 Metaspace 剩余空间容量的百分比,减少为 class metadata 释放空间导致的垃圾收集。

为什么要使用元空间代替永久代?

表面上看是为了避免 OOM 异常。因为通常使用 PermSize 和 MaxPermSize 设置永久代的大小就决定了永久代的上限,但是不是总能知道应该设置为多大合适,如果使用默认值很容易遇到 OOM 错误。所以将其丢到本地内存,只要本地内存足够,它不会出现像永久代中的 “java.lang.OutOfMemoryError: PermGenspace”

当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

当使用元空间时,可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。

更深层的原因还是要合并 HotSpot 和 JRockit 的代码,使用了元空间取代永久代,不用担心运行性能问题了,在覆盖到的测试中,取代后程序启动和运行速度降低不超过 1%,但是这点性能损失换来了更大的安全保障。

设置方法区的参数

元数据区大小可以使用参数 -XX:Metaspacesize-XX:MaxMetaspacesize 指定,替代方法区大小。

默认值依赖于平台。windows 下,-XX:MetaspaceSize 是 21M, -XX:MaxMetaspaceSize 的值是 -1,即没有限制。

与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。

如果元数据区发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace

-XX:MetaspaceSize: 设置初始的元空间大小。对于一个 64位的服务器端JVM来说,其默认的 -XX :MetaspaceSize 值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。

新的高水位线的值取决于 GC后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize 时,适当提高该值。如果释放空间过多,则适当降低该值。

如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

方法区的内部结构

它用于存储已被虚拟机加载的类型信息(构造方法 / 接口定义)、常量、静态变量、即时编译器编译后的代码缓存等。

这里的类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名
  • 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.object,都没有父类)
  • 这个类型的修饰符(public、abstract、final的某个子集)
  • 这个类型直接接口的一个有序列表

这里的域(Field)信息

JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括: 域名称、 域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

方法(Method)信息

JVM 必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或 void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native方法除外)
  • 异常表(abstract和native方法除外)每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

分析 class 文件

编写一个类并编译

public class Temp {
// 检查属性
public int num = 10;
private static String str = "测试方法的内部结构";

public static void main(String[] args) {
Temp temp = new Temp();
temp.sayHello("alsritter");
temp.add(1,9);
}

public void sayHello(String name) {
System.out.println("hello i'm " + name);
}

public int add(int a, int b) {
int c = a + b;
return c;
}
}

使用自带的 javap 分析 class 文件

# -p 表示显示 private 修饰的属性
javap -v -p Temp.class > temp.txt

下面省略不必要的信息

// 加载信息略....

// 类型信息
public class com.alsritter.studyjvm.Temp
minor version: 0
major version: 52
// 类的修饰符
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
// 这里省掉了常量池(看下一节的解析)
{
// 域信息
public int num;
descriptor: I
flags: ACC_PUBLIC

private static java.lang.String str; // 上面那个静态变量 str
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC

public com.alsritter.studyjvm.Temp();
// 默认构造器略(就是 <init>)...

// main 方法的执行
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
// 具体执行略,总之就是各种单指令...
// 指令位置映射略(可以看下面的那个 add 方法的,这里同理)...
LocalVariableTable: // 局部变量表
Start Length Slot Name Signature
0 23 0 args [Ljava/lang/String;
8 15 1 temp Lcom/alsritter/studyjvm/Temp;

// sayHello 方法略...

// add 方法
public int add(int, int);
descriptor: (II)I // 返回值类型
flags: ACC_PUBLIC // 访问修饰符
Code:
stack=2, locals=4, args_size=3 // 操作栈为2,局部变量表是4,参数数量是3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_3
5: ireturn
LineNumberTable:
line 23: 0 // 这里表示在源文件的 23 行对应上面的 Code 第 0 个命令 iload_1
line 24: 4
LocalVariableTable: // 局部变量表
Start Length Slot Name Signature
0 6 0 this Lcom/alsritter/studyjvm/Temp;
0 6 1 a I
0 6 2 b I
4 2 3 c I
}
SourceFile: "Temp.java"

字节码文件的常量池理解

方法区,内部包含了运行时常量池。 字节码文件,内部包含了常量池。

要弄清楚方法区,需要理解清楚字节码文件,因为加载类的信息都在方法区。 要弄清楚方法区的运行时常量池,需要理解清楚字节码文件中的常量池。

这里看一下上一节省略掉的 Constant pool

Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // com/alsritter/studyjvm/Temp.num:I
#8 = Class #10 // com/alsritter/studyjvm/Temp
#9 = NameAndType #11:#12 // num:I
#10 = Utf8 com/alsritter/studyjvm/Temp
#11 = Utf8 num
#12 = Utf8 I
#13 = Methodref #8.#3 // com/alsritter/studyjvm/Temp."<init>":()V
#14 = String #15 // alsritter
#15 = Utf8 alsritter
#16 = Methodref #8.#17 // com/alsritter/studyjvm/Temp.sayHello:(Ljava/lang/String;)V
#17 = NameAndType #18:#19 // sayHello:(Ljava/lang/String;)V
#18 = Utf8 sayHello
#19 = Utf8 (Ljava/lang/String;)V
#20 = Methodref #8.#21 // com/alsritter/studyjvm/Temp.add:(II)I
#21 = NameAndType #22:#23 // add:(II)I
#22 = Utf8 add
#23 = Utf8 (II)I
#24 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#25 = Class #27 // java/lang/System
#26 = NameAndType #28:#29 // out:Ljava/io/PrintStream;
#27 = Utf8 java/lang/System
#28 = Utf8 out
#29 = Utf8 Ljava/io/PrintStream;
#30 = Class #31 // java/lang/StringBuilder
#31 = Utf8 java/lang/StringBuilder
#32 = Methodref #30.#3 // java/lang/StringBuilder."<init>":()V
#33 = String #34 // hello i'm
#34 = Utf8 hello i'm
#35 = Methodref #30.#36 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#36 = NameAndType #37:#38 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#37 = Utf8 append
#38 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#39 = Methodref #30.#40 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#40 = NameAndType #41:#42 // toString:()Ljava/lang/String;
#41 = Utf8 toString
#42 = Utf8 ()Ljava/lang/String;
#43 = Methodref #44.#45 // java/io/PrintStream.println:(Ljava/lang/String;)V
#44 = Class #46 // java/io/PrintStream
#45 = NameAndType #47:#19 // println:(Ljava/lang/String;)V
#46 = Utf8 java/io/PrintStream
#47 = Utf8 println
#48 = String #49 // 测试方法的内部结构
#49 = Utf8 测试方法的内部结构
#50 = Fieldref #8.#51 // com/alsritter/studyjvm/Temp.str:Ljava/lang/String;
#51 = NameAndType #52:#53 // str:Ljava/lang/String;
#52 = Utf8 str
#53 = Utf8 Ljava/lang/String;
#54 = Utf8 Code
#55 = Utf8 LineNumberTable
#56 = Utf8 LocalVariableTable
#57 = Utf8 this
#58 = Utf8 Lcom/alsritter/studyjvm/Temp;
#59 = Utf8 main
#60 = Utf8 ([Ljava/lang/String;)V
#61 = Utf8 args
#62 = Utf8 [Ljava/lang/String;
#63 = Utf8 temp
#64 = Utf8 name
#65 = Utf8 a
#66 = Utf8 b
#67 = Utf8 c
#68 = Utf8 <clinit>
#69 = Utf8 SourceFile
#70 = Utf8 Temp.java

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。

那为什么需要一个常量池呢?

一个 Java源文件中的类、接口,编译后产生一个字节码文件。而 Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

比如,如下代码:

public class SimpleClass {
public static void main(String[] args) {
System.out.println("hello");
}
}

编译出来的文件大小 500字节,但是里面却引用了很多类,例如 Object、String、System、PrintStream 等,这些引用的结构就需要使用常量池,否则单字节码文件会变得很大

运行时常量池理解

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

在加载类和接口到虚拟机后,就会根据常量池创建对应的运行时常量池。

JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性(就是可能会动态加载其它的常量)。

运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。

当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM会抛OutOfMemoryError 异常。

图解方法区使用

使用如下案例进行方法区使用举例:

public class MethodAreaDemo {
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a+b);
}
}

具体字节码就不帖了,太长了

字节码执行过程展示

首先将操作数 500放入到操作数栈中

然后存储到局部变量表中

然后重复一次,把 100放入局部变量表中,最后再将变量表中的 500 和 100 取出,进行操作

将500 和 100 进行一个除法运算,在把结果入栈

在最后就是输出流,需要调用运行时常量池的常量

最后调用 invokevirtual(虚方法调用),然后返回

返回时

程序计数器始终计算的都是当前代码运行的位置,目的是为了方便记录 方法调用后能够正常返回,或者是进行了CPU切换后,也能回来到原来的代码进行执行。

方法区的演进细节

首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一

HotSpot 中方法区的变化

版本常量池位置
jdk1.6及之前有永久代,静态变量存放在永久代上
jdk1.7有永久代,但已经逐步 “去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

JDK6 的时候

JDK7 的时候

JDK8 的时候,元空间大小只受物理内存的影响

方法区垃圾回收

有些人认为方法区(如 Hotspot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。

《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。

事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 ZGC 收集器就不支持类卸载)。 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

先来说说方法区内常量池之中主要存放的两大类常量:

字面量和符号引用。

字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。 而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收 Java 堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)

判定一个常量是否 “废弃” 还是相对简单,而要判定一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 osGi、JSP 的重加载等,否则通常是很难达成的。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是 “被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClass-Loading-XX:+TraceClassUnLoading 查看类加载和卸载信息

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP以及 oSGi这类频繁自定义类加载器的场景中,通常都需要 Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。